diff options
Diffstat (limited to 'src/app/(main)/websites/[websiteId]/(reports)')
31 files changed, 2151 insertions, 0 deletions
diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx new file mode 100644 index 0000000..264923a --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx @@ -0,0 +1,128 @@ +import { Column, Grid } from '@umami/react-zen'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Panel } from '@/components/common/Panel'; +import { SectionHeader } from '@/components/common/SectionHeader'; +import { useMessages, useResultQuery } from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { percentFilter } from '@/lib/filters'; +import { formatLongNumber } from '@/lib/format'; + +export interface AttributionProps { + websiteId: string; + startDate: Date; + endDate: Date; + model: string; + type: string; + step: string; + currency?: string; +} + +export function Attribution({ + websiteId, + startDate, + endDate, + model, + type, + step, + currency, +}: AttributionProps) { + const { data, error, isLoading } = useResultQuery<any>('attribution', { + websiteId, + startDate, + endDate, + model, + type, + step, + }); + + const { formatMessage, labels } = useMessages(); + + const { pageviews, visitors, visits } = data?.total || {}; + + const metrics = data + ? [ + { + value: visitors, + label: formatMessage(labels.visitors), + formatValue: formatLongNumber, + }, + { + value: visits, + label: formatMessage(labels.visits), + formatValue: formatLongNumber, + }, + { + value: pageviews, + label: formatMessage(labels.views), + formatValue: formatLongNumber, + }, + ] + : []; + + function AttributionTable({ data = [], title }: { data: any; title: string }) { + const attributionData = percentFilter( + data.map(({ name, value }) => ({ + x: name, + y: Number(value), + })), + ); + + return ( + <ListTable + title={title} + metric={formatMessage(currency ? labels.revenue : labels.visitors)} + currency={currency} + data={attributionData.map(({ x, y, z }: { x: string; y: number; z: number }) => ({ + label: x, + count: y, + percent: z, + }))} + /> + ); + } + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + {data && ( + <Column gap> + <MetricsBar> + {metrics?.map(({ label, value, formatValue }) => { + return ( + <MetricCard key={label} value={value} label={label} formatValue={formatValue} /> + ); + })} + </MetricsBar> + <SectionHeader title={formatMessage(labels.sources)} /> + <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap> + <Panel> + <AttributionTable data={data?.referrer} title={formatMessage(labels.referrer)} /> + </Panel> + <Panel> + <AttributionTable data={data?.paidAds} title={formatMessage(labels.paidAds)} /> + </Panel> + </Grid> + <SectionHeader title="UTM" /> + <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap> + <Panel> + <AttributionTable data={data?.utm_source} title={formatMessage(labels.sources)} /> + </Panel> + <Panel> + <AttributionTable data={data?.utm_medium} title={formatMessage(labels.medium)} /> + </Panel> + <Panel> + <AttributionTable data={data?.utm_cmapaign} title={formatMessage(labels.campaigns)} /> + </Panel> + <Panel> + <AttributionTable data={data?.utm_content} title={formatMessage(labels.content)} /> + </Panel> + <Panel> + <AttributionTable data={data?.utm_term} title={formatMessage(labels.terms)} /> + </Panel> + </Grid> + </Column> + )} + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx new file mode 100644 index 0000000..48611c4 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx @@ -0,0 +1,63 @@ +'use client'; +import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen'; +import { useState } from 'react'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { Attribution } from './Attribution'; + +export function AttributionPage({ websiteId }: { websiteId: string }) { + const [model, setModel] = useState('first-click'); + const [type, setType] = useState('path'); + const [step, setStep] = useState('/'); + const { formatMessage, labels } = useMessages(); + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + + return ( + <Column gap="6"> + <WebsiteControls websiteId={websiteId} /> + <Grid columns={{ xs: '1fr', md: '1fr 1fr 1fr' }} gap> + <Column> + <Select + label={formatMessage(labels.model)} + value={model} + defaultValue={model} + onChange={setModel} + > + <ListItem id="first-click">{formatMessage(labels.firstClick)}</ListItem> + <ListItem id="last-click">{formatMessage(labels.lastClick)}</ListItem> + </Select> + </Column> + <Column> + <Select + label={formatMessage(labels.type)} + value={type} + defaultValue={type} + onChange={setType} + > + <ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem> + <ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem> + </Select> + </Column> + <Column> + <SearchField + label={formatMessage(labels.conversionStep)} + value={step} + defaultValue={step} + onSearch={setStep} + delay={1000} + /> + </Column> + </Grid> + <Attribution + websiteId={websiteId} + startDate={startDate} + endDate={endDate} + model={model} + type={type} + step={step} + /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx new file mode 100644 index 0000000..1368d4b --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { AttributionPage } from './AttributionPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <AttributionPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Attribution', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx new file mode 100644 index 0000000..4532d97 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx @@ -0,0 +1,91 @@ +import { Column, DataColumn, DataTable, Text } from '@umami/react-zen'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useFields, useFormat, useMessages, useResultQuery } from '@/components/hooks'; +import { formatShortTime } from '@/lib/format'; + +export interface BreakdownProps { + websiteId: string; + startDate: Date; + endDate: Date; + selectedFields: string[]; +} + +export function Breakdown({ websiteId, selectedFields = [], startDate, endDate }: BreakdownProps) { + const { formatMessage, labels } = useMessages(); + const { formatValue } = useFormat(); + const { fields } = useFields(); + const { data, error, isLoading } = useResultQuery<any>( + 'breakdown', + { + websiteId, + startDate, + endDate, + fields: selectedFields, + }, + { enabled: !!selectedFields.length }, + ); + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + <Column overflow="auto" minHeight="0" height="100%"> + <DataTable data={data} style={{ tableLayout: 'fixed' }}> + {selectedFields.map(field => { + return ( + <DataColumn + key={field} + id={field} + label={fields.find(f => f.name === field)?.label} + width="minmax(120px, 1fr)" + > + {row => { + const value = formatValue(row[field], field); + return ( + <Text truncate title={value}> + {value} + </Text> + ); + }} + </DataColumn> + ); + })} + <DataColumn + id="visitors" + label={formatMessage(labels.visitors)} + align="end" + width="120px" + > + {row => row?.visitors?.toLocaleString()} + </DataColumn> + <DataColumn id="visits" label={formatMessage(labels.visits)} align="end" width="120px"> + {row => row?.visits?.toLocaleString()} + </DataColumn> + <DataColumn id="views" label={formatMessage(labels.views)} align="end" width="120px"> + {row => row?.views?.toLocaleString()} + </DataColumn> + <DataColumn + id="bounceRate" + label={formatMessage(labels.bounceRate)} + align="end" + width="120px" + > + {row => { + const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100; + return `${Math.round(+n)}%`; + }} + </DataColumn> + <DataColumn + id="visitDuration" + label={formatMessage(labels.visitDuration)} + align="end" + width="120px" + > + {row => { + const n = row?.totaltime / row?.visits; + return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`; + }} + </DataColumn> + </DataTable> + </Column> + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx new file mode 100644 index 0000000..fdead9f --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx @@ -0,0 +1,51 @@ +'use client'; +import { Column, Row } from '@umami/react-zen'; +import { useState } from 'react'; +import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { Panel } from '@/components/common/Panel'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { ListCheck } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { Breakdown } from './Breakdown'; + +export function BreakdownPage({ websiteId }: { websiteId: string }) { + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + const [fields, setFields] = useState(['path']); + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <Row alignItems="center" justifyContent="flex-start"> + <FieldsButton value={fields} onChange={setFields} /> + </Row> + <Panel height="900px" overflow="auto" allowFullscreen> + <Breakdown + websiteId={websiteId} + startDate={startDate} + endDate={endDate} + selectedFields={fields} + /> + </Panel> + </Column> + ); +} + +const FieldsButton = ({ value, onChange }) => { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<ListCheck />} + label={formatMessage(labels.fields)} + width="400px" + minHeight="300px" + variant="outline" + > + {({ close }) => { + return <FieldSelectForm selectedFields={value} onChange={onChange} onClose={close} />; + }} + </DialogButton> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx new file mode 100644 index 0000000..28e3368 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx @@ -0,0 +1,46 @@ +import { Button, Column, Grid, List, ListItem } from '@umami/react-zen'; +import { useState } from 'react'; +import { useFields, useMessages } from '@/components/hooks'; + +export function FieldSelectForm({ + selectedFields = [], + onChange, + onClose, +}: { + selectedFields?: string[]; + onChange: (values: string[]) => void; + onClose?: () => void; +}) { + const [selected, setSelected] = useState(selectedFields); + const { formatMessage, labels } = useMessages(); + const { fields } = useFields(); + + const handleChange = (value: string[]) => { + setSelected(value); + }; + + const handleApply = () => { + onChange?.(selected); + onClose(); + }; + + return ( + <Column gap="6"> + <List value={selected} onChange={handleChange} selectionMode="multiple"> + {fields.map(({ name, label }) => { + return ( + <ListItem key={name} id={name}> + {label} + </ListItem> + ); + })} + </List> + <Grid columns="1fr 1fr" gap> + <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> + <Button onPress={handleApply} variant="primary"> + {formatMessage(labels.apply)} + </Button> + </Grid> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx new file mode 100644 index 0000000..841d863 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { BreakdownPage } from './BreakdownPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <BreakdownPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Insights', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx new file mode 100644 index 0000000..e336a3d --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx @@ -0,0 +1,134 @@ +import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useResultQuery } from '@/components/hooks'; +import { File, User } from '@/components/icons'; +import { ReportEditButton } from '@/components/input/ReportEditButton'; +import { ChangeLabel } from '@/components/metrics/ChangeLabel'; +import { Lightning } from '@/components/svg'; +import { formatLongNumber } from '@/lib/format'; +import { FunnelEditForm } from './FunnelEditForm'; + +type FunnelResult = { + type: string; + value: string; + visitors: number; + previous: number; + dropped: number; + dropoff: number; + remaining: number; +}; + +export function Funnel({ id, name, type, parameters, websiteId }) { + const { formatMessage, labels } = useMessages(); + const { data, error, isLoading } = useResultQuery(type, { + websiteId, + ...parameters, + }); + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + <Grid gap> + <Grid columns="1fr auto" gap> + <Column gap> + <Row> + <Text size="4" weight="bold"> + {name} + </Text> + </Row> + </Column> + <Column> + <ReportEditButton id={id} name={name} type={type}> + {({ close }) => { + return ( + <Dialog + title={formatMessage(labels.funnel)} + variant="modal" + style={{ minHeight: 300, minWidth: 400 }} + > + <FunnelEditForm id={id} websiteId={websiteId} onClose={close} /> + </Dialog> + ); + }} + </ReportEditButton> + </Column> + </Grid> + {data?.map( + ( + { type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult, + index: number, + ) => { + const isPage = type === 'path'; + return ( + <Grid key={index} columns="auto 1fr" gap="6"> + <Column alignItems="center" position="relative"> + <Row + borderRadius="full" + backgroundColor="3" + width="40px" + height="40px" + justifyContent="center" + alignItems="center" + style={{ zIndex: 1 }} + > + <Text weight="bold" size="3"> + {index + 1} + </Text> + </Row> + {index > 0 && ( + <Box + position="absolute" + backgroundColor="3" + width="2px" + height="120px" + top="-100%" + /> + )} + </Column> + <Column gap> + <Row alignItems="center" justifyContent="space-between" gap> + <Text color="muted"> + {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)} + </Text> + <Text color="muted">{formatMessage(labels.conversionRate)}</Text> + </Row> + <Row alignItems="center" justifyContent="space-between" gap> + <Row alignItems="center" gap> + <Icon>{type === 'path' ? <File /> : <Lightning />}</Icon> + <Text>{value}</Text> + </Row> + <Row alignItems="center" gap> + {index > 0 && ( + <ChangeLabel value={-dropped} title={`${-Math.round(dropoff * 100)}%`}> + {formatLongNumber(dropped)} + </ChangeLabel> + )} + <Icon> + <User /> + </Icon> + <Text title={visitors.toString()} transform="lowercase"> + {`${formatLongNumber(visitors)} ${formatMessage(labels.visitors)}`} + </Text> + </Row> + </Row> + <Row alignItems="center" gap="6"> + <ProgressBar + value={visitors || 0} + minValue={0} + maxValue={previous || 1} + style={{ width: '100%' }} + /> + <Row minWidth="90px" justifyContent="end"> + <Text weight="bold" size="7"> + {Math.round(remaining * 100)}% + </Text> + </Row> + </Row> + </Column> + </Grid> + ); + }, + )} + </Grid> + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx new file mode 100644 index 0000000..29b5480 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx @@ -0,0 +1,28 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { FunnelEditForm } from './FunnelEditForm'; + +export function FunnelAddButton({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogTrigger> + <Button variant="primary"> + <Icon> + <Plus /> + </Icon> + <Text>{formatMessage(labels.funnel)}</Text> + </Button> + <Modal> + <Dialog + variant="modal" + title={formatMessage(labels.funnel)} + style={{ minHeight: 375, minWidth: 600 }} + > + {({ close }) => <FunnelEditForm websiteId={websiteId} onClose={close} />} + </Dialog> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx new file mode 100644 index 0000000..5d950ea --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx @@ -0,0 +1,141 @@ +import { + Button, + Column, + Form, + FormButtons, + FormField, + FormFieldArray, + FormSubmitButton, + Grid, + Icon, + Loading, + Row, + Text, + TextField, +} from '@umami/react-zen'; +import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks'; +import { Plus, X } from '@/components/icons'; +import { ActionSelect } from '@/components/input/ActionSelect'; +import { LookupField } from '@/components/input/LookupField'; + +const FUNNEL_STEPS_MAX = 8; + +export function FunnelEditForm({ + id, + websiteId, + onSave, + onClose, +}: { + id?: string; + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { data } = useReportQuery(id); + const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`); + + const handleSubmit = async ({ name, ...parameters }) => { + await mutateAsync( + { ...data, id, name, type: 'funnel', websiteId, parameters }, + { + onSuccess: async () => { + touch('reports:funnel'); + touch(`report:${id}`); + onSave?.(); + onClose?.(); + }, + }, + ); + }; + + if (id && !data) { + return <Loading placement="absolute" />; + } + + const defaultValues = { + name: data?.name || '', + window: data?.parameters?.window || 60, + steps: data?.parameters?.steps || [{ type: 'path', value: '' }], + }; + + return ( + <Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}> + <FormField + name="name" + label={formatMessage(labels.name)} + rules={{ required: formatMessage(labels.required) }} + > + <TextField autoFocus /> + </FormField> + <FormField + name="window" + label={formatMessage(labels.window)} + rules={{ required: formatMessage(labels.required) }} + > + <TextField /> + </FormField> + <FormFieldArray + name="steps" + label={formatMessage(labels.steps)} + rules={{ + validate: value => value.length > 1 || 'At least two steps are required', + }} + > + {({ fields, append, remove }) => { + return ( + <Grid gap> + {fields.map(({ id }: { id: string }, index: number) => { + return ( + <Grid key={id} columns="260px 1fr auto" gap> + <Column> + <FormField + name={`steps.${index}.type`} + rules={{ required: formatMessage(labels.required) }} + > + <ActionSelect /> + </FormField> + </Column> + <Column> + <FormField + name={`steps.${index}.value`} + rules={{ required: formatMessage(labels.required) }} + > + {({ field, context }) => { + const type = context.watch(`steps.${index}.type`); + return <LookupField websiteId={websiteId} type={type} {...field} />; + }} + </FormField> + </Column> + <Button onPress={() => remove(index)}> + <Icon size="sm"> + <X /> + </Icon> + </Button> + </Grid> + ); + })} + <Row> + <Button + onPress={() => append({ type: 'path', value: '' })} + isDisabled={fields.length >= FUNNEL_STEPS_MAX} + > + <Icon> + <Plus /> + </Icon> + <Text>{formatMessage(labels.add)}</Text> + </Button> + </Row> + </Grid> + ); + }} + </FormFieldArray> + <FormButtons> + <Button onPress={onClose} isDisabled={isPending}> + {formatMessage(labels.cancel)} + </Button> + <FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx new file mode 100644 index 0000000..57bce52 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx @@ -0,0 +1,36 @@ +'use client'; +import { Column, Grid } from '@umami/react-zen'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Panel } from '@/components/common/Panel'; +import { SectionHeader } from '@/components/common/SectionHeader'; +import { useDateRange, useReportsQuery } from '@/components/hooks'; +import { Funnel } from './Funnel'; +import { FunnelAddButton } from './FunnelAddButton'; + +export function FunnelsPage({ websiteId }: { websiteId: string }) { + const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'funnel' }); + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <SectionHeader> + <FunnelAddButton websiteId={websiteId} /> + </SectionHeader> + <LoadingPanel data={data} isLoading={isLoading} error={error}> + {data && ( + <Grid gap> + {data.data?.map((report: any) => ( + <Panel key={report.id}> + <Funnel {...report} startDate={startDate} endDate={endDate} /> + </Panel> + ))} + </Grid> + )} + </LoadingPanel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx new file mode 100644 index 0000000..2fdcf3b --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { FunnelsPage } from './FunnelsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <FunnelsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Funnels', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx new file mode 100644 index 0000000..b6c4a11 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx @@ -0,0 +1,99 @@ +import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useResultQuery } from '@/components/hooks'; +import { File, User } from '@/components/icons'; +import { ReportEditButton } from '@/components/input/ReportEditButton'; +import { Lightning } from '@/components/svg'; +import { formatLongNumber } from '@/lib/format'; +import { GoalEditForm } from './GoalEditForm'; + +export interface GoalProps { + id: string; + name: string; + type: string; + parameters: { + name: string; + type: string; + value: string; + }; + websiteId: string; + startDate: Date; + endDate: Date; +} + +export type GoalData = { num: number; total: number }; + +export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) { + const { formatMessage, labels } = useMessages(); + const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, { + websiteId, + startDate, + endDate, + ...parameters, + }); + const isPage = parameters?.type === 'path'; + + return ( + <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}> + {data && ( + <Grid gap> + <Grid columns="1fr auto" gap> + <Column gap> + <Row> + <Text size="4" weight="bold"> + {name} + </Text> + </Row> + </Column> + <Column> + <ReportEditButton id={id} name={name} type={type}> + {({ close }) => { + return ( + <Dialog + title={formatMessage(labels.goal)} + variant="modal" + style={{ minHeight: 300, minWidth: 400 }} + > + <GoalEditForm id={id} websiteId={websiteId} onClose={close} /> + </Dialog> + ); + }} + </ReportEditButton> + </Column> + </Grid> + <Row alignItems="center" justifyContent="space-between" gap> + <Text color="muted"> + {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)} + </Text> + <Text color="muted">{formatMessage(labels.conversionRate)}</Text> + </Row> + <Row alignItems="center" justifyContent="space-between" gap> + <Row alignItems="center" gap> + <Icon>{parameters.type === 'path' ? <File /> : <Lightning />}</Icon> + <Text>{parameters.value}</Text> + </Row> + <Row alignItems="center" gap> + <Icon> + <User /> + </Icon> + <Text title={`${data?.num} / ${data?.total}`}>{`${formatLongNumber( + data?.num, + )} / ${formatLongNumber(data?.total)}`}</Text> + </Row> + </Row> + <Row alignItems="center" gap="6"> + <ProgressBar + value={data?.num || 0} + minValue={0} + maxValue={data?.total || 1} + style={{ width: '100%' }} + /> + <Text weight="bold" size="7"> + {data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}% + </Text> + </Row> + </Grid> + )} + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx new file mode 100644 index 0000000..c85b79c --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx @@ -0,0 +1,28 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { GoalEditForm } from './GoalEditForm'; + +export function GoalAddButton({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogTrigger> + <Button variant="primary"> + <Icon> + <Plus /> + </Icon> + <Text>{formatMessage(labels.goal)}</Text> + </Button> + <Modal> + <Dialog + aria-label="add goal" + title={formatMessage(labels.goal)} + style={{ minWidth: 400, minHeight: 300 }} + > + {({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />} + </Dialog> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx new file mode 100644 index 0000000..7f68047 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx @@ -0,0 +1,104 @@ +import { + Button, + Column, + Form, + FormButtons, + FormField, + FormSubmitButton, + Grid, + Label, + Loading, + TextField, +} from '@umami/react-zen'; +import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks'; +import { ActionSelect } from '@/components/input/ActionSelect'; +import { LookupField } from '@/components/input/LookupField'; + +export function GoalEditForm({ + id, + websiteId, + onSave, + onClose, +}: { + id?: string; + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { data } = useReportQuery(id); + const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`); + + const handleSubmit = async (formData: Record<string, any>) => { + await mutateAsync( + { ...formData, type: 'goal', websiteId }, + { + onSuccess: async () => { + if (id) touch(`report:${id}`); + touch('reports:goal'); + onSave?.(); + onClose?.(); + }, + }, + ); + }; + + if (id && !data) { + return <Loading placement="absolute" />; + } + + const defaultValues = { + name: '', + parameters: { type: 'path', value: '' }, + }; + + return ( + <Form onSubmit={handleSubmit} error={error?.message} defaultValues={data || defaultValues}> + {({ watch }) => { + const type = watch('parameters.type'); + + return ( + <> + <FormField + name="name" + label={formatMessage(labels.name)} + rules={{ required: formatMessage(labels.required) }} + > + <TextField autoFocus /> + </FormField> + <Column> + <Label>{formatMessage(labels.action)}</Label> + <Grid columns="260px 1fr" gap> + <Column> + <FormField + name="parameters.type" + rules={{ required: formatMessage(labels.required) }} + > + <ActionSelect /> + </FormField> + </Column> + <Column> + <FormField + name="parameters.value" + rules={{ required: formatMessage(labels.required) }} + > + {({ field }) => { + return <LookupField websiteId={websiteId} type={type} {...field} />; + }} + </FormField> + </Column> + </Grid> + </Column> + + <FormButtons> + <Button onPress={onClose} isDisabled={isPending}> + {formatMessage(labels.cancel)} + </Button> + <FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton> + </FormButtons> + </> + ); + }} + </Form> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx new file mode 100644 index 0000000..ff7b49f --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx @@ -0,0 +1,36 @@ +'use client'; +import { Column, Grid } from '@umami/react-zen'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Panel } from '@/components/common/Panel'; +import { SectionHeader } from '@/components/common/SectionHeader'; +import { useDateRange, useReportsQuery } from '@/components/hooks'; +import { Goal } from './Goal'; +import { GoalAddButton } from './GoalAddButton'; + +export function GoalsPage({ websiteId }: { websiteId: string }) { + const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'goal' }); + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <SectionHeader> + <GoalAddButton websiteId={websiteId} /> + </SectionHeader> + <LoadingPanel data={data} isLoading={isLoading} error={error}> + {data && ( + <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap> + {data.data.map((report: any) => ( + <Panel key={report.id}> + <Goal {...report} startDate={startDate} endDate={endDate} /> + </Panel> + ))} + </Grid> + )} + </LoadingPanel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx new file mode 100644 index 0000000..b1ab691 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { GoalsPage } from './GoalsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <GoalsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Goals', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css new file mode 100644 index 0000000..63643f1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css @@ -0,0 +1,267 @@ +.container { + width: 100%; + height: 100%; + position: relative; + + --journey-line-color: var(--base-color-6); + --journey-active-color: var(--primary-color); + --journey-faded-color: var(--base-color-3); +} + +.view { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + overflow: auto; + gap: 100px; + padding-right: 20px; +} + +.header { + margin-bottom: 20px; +} + +.stats { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 10px; + width: 100%; +} + +.visitors { + font-weight: 600; + font-size: 16px; + text-transform: lowercase; +} + +.dropoff { + font-weight: 600; + color: var(--font-color-muted); + background: var(--base-color-2); + padding: 4px 8px; + border-radius: 5px; +} + +.num { + display: flex; + align-items: center; + justify-content: center; + border-radius: 100%; + width: 50px; + height: 50px; + font-size: 16px; + font-weight: 700; + color: var(--base-color-1); + background: var(--base-color-12); + z-index: 1; + margin: 0 auto 20px; +} + +.column { + display: flex; + flex-direction: column; +} + +.nodes { + position: relative; + display: flex; + flex-direction: column; + height: 100%; +} + +.wrapper { + padding-bottom: 10px; +} + +.node { + position: relative; + cursor: pointer; + padding: 10px 20px; + background: var(--base-color-3); + border-radius: 5px; + display: flex; + align-items: center; + justify-content: space-between; + width: 300px; + max-width: 300px; + height: 60px; + max-height: 60px; +} + +.node:hover:not(.selected) { + background: var(--base-color-4); +} + +.node.selected { + color: var(--base-color-1); + background: var(--base-color-12); +} + +.node.active { + color: var(--primary-font-color); + background: var(--primary-color); +} + +.node.selected .count { + color: var(--base-color-1); + background: var(--base-color-12); +} + +.node.selected.active .count { + color: var(--primary-font-color); + background: var(--primary-color); +} + +.name { + max-width: 200px; +} + +.line { + position: absolute; + bottom: 0; + left: -100px; + width: 100px; + pointer-events: none; +} + +.line.up { + bottom: 0; +} + +.line.down { + top: 0; +} + +.segment { + position: absolute; +} + +.start { + left: 0; + width: 50px; + height: 30px; + border: 0; +} + +.mid { + top: 60px; + width: 50px; + border-right: 3px solid var(--journey-line-color); +} + +.end { + width: 50px; + height: 30px; + border: 0; +} + +.up .start { + top: 30px; + border-top-right-radius: 100%; + border-top: 3px solid var(--journey-line-color); + border-right: 3px solid var(--journey-line-color); +} + +.up .end { + width: 52px; + bottom: 27px; + right: 0; + border-bottom-left-radius: 100%; + border-bottom: 3px solid var(--journey-line-color); + border-left: 3px solid var(--journey-line-color); +} + +.down .start { + bottom: 27px; + border-bottom-right-radius: 100%; + border-bottom: 3px solid var(--journey-line-color); + border-right: 3px solid var(--journey-line-color); +} + +.down .end { + width: 52px; + top: 30px; + right: 0; + border-top-left-radius: 100%; + border-top: 3px solid var(--journey-line-color); + border-left: 3px solid var(--journey-line-color); +} + +.flat .start { + left: 0; + top: 30px; + border-top: 3px solid var(--journey-line-color); +} + +.flat .end { + right: 0; + top: 30px; + border-top: 3px solid var(--journey-line-color); +} + +.start:before, +.end:before { + content: ""; + position: absolute; + border-radius: 100%; + border: 3px solid var(--journey-line-color); + background: var(--base-color-1); + width: 14px; + height: 14px; +} + +.line:not(.active) .start:before, +.line:not(.active) .end:before { + display: none; +} + +.up .start:before { + left: -8px; + top: -8px; +} + +.up .end:before { + right: -8px; + bottom: -8px; +} + +.down .start:before { + left: -8px; + bottom: -8px; +} + +.down .end:before { + right: -8px; + top: -8px; +} + +.flat .start:before { + left: -8px; + top: -8px; +} + +.flat .end:before { + right: -8px; + top: -8px; +} + +.line.active .segment, +.line.active .segment:before { + border-color: var(--journey-active-color); + z-index: 1; +} + +.column.active .line:not(.active) .segment { + border-color: var(--journey-faded-color); +} + +.column.active .line:not(.active) .segment:before { + display: none; +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx new file mode 100644 index 0000000..3327a42 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx @@ -0,0 +1,294 @@ +import { Column, Focusable, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import classNames from 'classnames'; +import { useMemo, useState } from 'react'; +import { firstBy } from 'thenby'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks'; +import { File } from '@/components/icons'; +import { Lightning } from '@/components/svg'; +import { objectToArray } from '@/lib/data'; +import { formatLongNumber } from '@/lib/format'; +import styles from './Journey.module.css'; + +const NODE_HEIGHT = 60; +const NODE_GAP = 10; +const LINE_WIDTH = 3; + +export interface JourneyProps { + websiteId: string; + startDate: Date; + endDate: Date; + steps: number; + startStep?: string; + endStep?: string; +} + +export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) { + const [selectedNode, setSelectedNode] = useState(null); + const [activeNode, setActiveNode] = useState(null); + const { formatMessage, labels } = useMessages(); + const { data, error, isLoading } = useResultQuery<any>('journey', { + websiteId, + steps, + startStep, + endStep, + }); + + useEscapeKey(() => setSelectedNode(null)); + + const columns = useMemo(() => { + if (!data) { + return []; + } + + const selectedPaths = selectedNode?.paths ?? []; + const activePaths = activeNode?.paths ?? []; + const columns = []; + + for (let columnIndex = 0; columnIndex < +steps; columnIndex++) { + const nodes = {}; + + data.forEach(({ items, count }: any, nodeIndex: any) => { + const name = items[columnIndex]; + + if (name) { + const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name); + const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name); + + if (!nodes[name]) { + const paths = data.filter(({ items }) => items[columnIndex] === name); + + nodes[name] = { + name, + count, + totalCount: count, + nodeIndex, + columnIndex, + selected, + active, + paths, + pathMap: paths.map(({ items, count }) => ({ + [`${columnIndex}:${items.join(':')}`]: count, + })), + }; + } else { + nodes[name].totalCount += count; + } + } + }); + + columns.push({ + nodes: objectToArray(nodes).sort(firstBy('total', -1)), + }); + } + + columns.forEach((column, columnIndex) => { + const nodes = column.nodes.map( + ( + currentNode: { totalCount: number; name: string; selected: boolean }, + currentNodeIndex: any, + ) => { + const previousNodes = columns[columnIndex - 1]?.nodes; + let selectedCount = previousNodes ? 0 : currentNode.totalCount; + let activeCount = selectedCount; + + const lines = + previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => { + const fromCount = selectedNode?.paths.reduce((sum, path) => { + if ( + previousNode.name === path.items[columnIndex - 1] && + currentNode.name === path.items[columnIndex] + ) { + sum += path.count; + } + return sum; + }, 0); + + if (currentNode.selected && previousNode.selected && fromCount) { + arr.push([previousNodeIndex, currentNodeIndex]); + selectedCount += fromCount; + + if (previousNode.active) { + activeCount += fromCount; + } + } + + return arr; + }, []) || []; + + return { ...currentNode, selectedCount, activeCount, lines }; + }, + ); + + const visitorCount = nodes.reduce( + (sum: number, { selected, selectedCount, active, activeCount, totalCount }) => { + if (!selectedNode) { + sum += totalCount; + } else if (!activeNode && selectedNode && selected) { + sum += selectedCount; + } else if (activeNode && active) { + sum += activeCount; + } + return sum; + }, + 0, + ); + + const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0; + const dropOff = + previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0; + + Object.assign(column, { nodes, visitorCount, dropOff }); + }); + + return columns; + }, [data, selectedNode, activeNode]); + + const handleClick = (name: string, columnIndex: number, paths: any[]) => { + if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) { + setSelectedNode({ name, columnIndex, paths }); + } else { + setSelectedNode(null); + } + setActiveNode(null); + }; + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error} height="100%"> + <div className={styles.container}> + <div className={styles.view}> + {columns.map(({ visitorCount, nodes }, columnIndex) => { + return ( + <div + key={columnIndex} + className={classNames(styles.column, { + [styles.selected]: selectedNode, + [styles.active]: activeNode, + })} + > + <div className={styles.header}> + <div className={styles.num}>{columnIndex + 1}</div> + <div className={styles.stats}> + <div className={styles.visitors} title={visitorCount}> + {formatLongNumber(visitorCount)} {formatMessage(labels.visitors)} + </div> + </div> + </div> + <div className={styles.nodes}> + {nodes.map( + ({ + name, + totalCount, + selected, + active, + paths, + activeCount, + selectedCount, + lines, + }) => { + const nodeCount = selected + ? active + ? activeCount + : selectedCount + : totalCount; + + const remaining = + columnIndex > 0 + ? Math.round((nodeCount / columns[columnIndex - 1]?.visitorCount) * 100) + : 0; + + const dropped = 100 - remaining; + + return ( + <div + key={name} + className={styles.wrapper} + onMouseEnter={() => + selected && setActiveNode({ name, columnIndex, paths }) + } + onMouseLeave={() => selected && setActiveNode(null)} + > + <div + className={classNames(styles.node, { + [styles.selected]: selected, + [styles.active]: active, + })} + onClick={() => handleClick(name, columnIndex, paths)} + > + <Row alignItems="center" className={styles.name} title={name} gap> + <Icon>{name.startsWith('/') ? <File /> : <Lightning />}</Icon> + <Text truncate>{name}</Text> + </Row> + <div className={styles.count} title={nodeCount}> + <TooltipTrigger + delay={0} + isDisabled={columnIndex === 0 || (selectedNode && !selected)} + > + <Focusable> + <div>{formatLongNumber(nodeCount)}</div> + </Focusable> + <Tooltip placement="top" offset={20} showArrow> + <Text transform="lowercase" color="ruby"> + {`${dropped}% ${formatMessage(labels.dropoff)}`} + </Text> + <Column> + <Text transform="lowercase"> + {`${remaining}% ${formatMessage(labels.conversion)}`} + </Text> + </Column> + </Tooltip> + </TooltipTrigger> + </div> + {columnIndex < columns.length && + lines.map(([fromIndex, nodeIndex], i) => { + const height = + (Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) - + NODE_GAP; + const midHeight = + (Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) + + NODE_GAP + + LINE_WIDTH; + const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name; + + return ( + <div + key={`${fromIndex}${nodeIndex}${i}`} + className={classNames(styles.line, { + [styles.active]: + active && + activeNode?.paths.find( + (path: { items: any[] }) => + path.items[columnIndex] === name && + path.items[columnIndex - 1] === nodeName, + ), + [styles.up]: fromIndex < nodeIndex, + [styles.down]: fromIndex > nodeIndex, + [styles.flat]: fromIndex === nodeIndex, + })} + style={{ height }} + > + <div className={classNames(styles.segment, styles.start)} /> + <div + className={classNames(styles.segment, styles.mid)} + style={{ + height: midHeight, + }} + /> + <div className={classNames(styles.segment, styles.end)} /> + </div> + ); + })} + </div> + </div> + ); + }, + )} + </div> + </div> + ); + })} + </div> + </div> + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx new file mode 100644 index 0000000..14b8341 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx @@ -0,0 +1,67 @@ +'use client'; +import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen'; +import { useState } from 'react'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { Panel } from '@/components/common/Panel'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { Journey } from './Journey'; + +const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7]; +const DEFAULT_STEP = 3; + +export function JourneysPage({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + const [steps, setSteps] = useState(DEFAULT_STEP); + const [startStep, setStartStep] = useState(''); + const [endStep, setEndStep] = useState(''); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <Grid columns="repeat(3, 1fr)" gap> + <Select + items={JOURNEY_STEPS} + label={formatMessage(labels.steps)} + value={steps} + defaultValue={steps} + onChange={setSteps} + > + {JOURNEY_STEPS.map(step => ( + <ListItem key={step} id={step}> + {step} + </ListItem> + ))} + </Select> + <Column> + <SearchField + label={formatMessage(labels.startStep)} + value={startStep} + onSearch={setStartStep} + delay={1000} + /> + </Column> + <Column> + <SearchField + label={formatMessage(labels.endStep)} + value={endStep} + onSearch={setEndStep} + delay={1000} + /> + </Column> + </Grid> + <Panel height="900px" allowFullscreen> + <Journey + websiteId={websiteId} + startDate={startDate} + endDate={endDate} + steps={steps} + startStep={startStep} + endStep={endStep} + /> + </Panel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx new file mode 100644 index 0000000..f6062a6 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { JourneysPage } from './JourneysPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <JourneysPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Journeys', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx new file mode 100644 index 0000000..fdd8a14 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx @@ -0,0 +1,140 @@ +import { Column, Grid, Icon, Row, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Panel } from '@/components/common/Panel'; +import { useLocale, useMessages, useResultQuery } from '@/components/hooks'; +import { Users } from '@/components/icons'; +import { formatDate } from '@/lib/date'; +import { formatLongNumber } from '@/lib/format'; + +const DAYS = [1, 2, 3, 4, 5, 6, 7, 14, 21, 28]; + +export interface RetentionProps { + websiteId: string; + startDate: Date; + endDate: Date; + days?: number[]; +} + +export function Retention({ websiteId, days = DAYS, startDate, endDate }: RetentionProps) { + const { formatMessage, labels } = useMessages(); + const { locale } = useLocale(); + const { data, error, isLoading } = useResultQuery('retention', { + websiteId, + startDate, + endDate, + }); + + const rows = + data?.reduce((arr: any[], row: { date: any; visitors: any; day: any }) => { + const { date, visitors, day } = row; + if (day === 0) { + return arr.concat({ + date, + visitors, + records: days + .reduce((arr, day) => { + arr[day] = data.find( + (x: { date: any; day: number }) => x.date === date && x.day === day, + ); + return arr; + }, []) + .filter(n => n), + }); + } + return arr; + }, []) || []; + + const totalDays = rows.length; + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + {data && ( + <Panel allowFullscreen height="900px"> + <Column + paddingY="6" + paddingX={{ xs: '3', md: '6' }} + position="absolute" + top="40px" + left="0" + right="0" + bottom="0" + > + <Column gap="1" overflow="auto"> + <Grid + columns="120px repeat(10, 100px)" + alignItems="center" + gap="1" + height="50px" + width="max-content" + minWidth="100%" + autoFlow="column" + > + <Column> + <Text weight="bold" align="center"> + {formatMessage(labels.cohort)} + </Text> + </Column> + {days.map(n => ( + <Column key={n}> + <Text weight="bold" align="center" wrap="nowrap"> + {formatMessage(labels.day)} {n} + </Text> + </Column> + ))} + </Grid> + {rows.map(({ date, visitors, records }: any, rowIndex: number) => { + return ( + <Grid + key={rowIndex} + columns="120px repeat(10, 100px)" + gap="1" + autoFlow="column" + width="max-content" + minWidth="100%" + > + <Column justifyContent="center" gap="1"> + <Text weight="bold">{formatDate(date, 'PP', locale)}</Text> + <Row alignItems="center" gap> + <Icon> + <Users /> + </Icon> + <Text>{formatLongNumber(visitors)}</Text> + </Row> + </Column> + {days.map(day => { + if (totalDays - rowIndex < day) { + return null; + } + const percentage = records.filter(a => a.day === day)[0]?.percentage; + return ( + <Cell key={day}> + {percentage ? `${Number(percentage).toFixed(2)}%` : ''} + </Cell> + ); + })} + </Grid> + ); + })} + </Column> + </Column> + </Panel> + )} + </LoadingPanel> + ); +} + +const Cell = ({ children }: { children: ReactNode }) => { + return ( + <Column + justifyContent="center" + alignItems="center" + width="100px" + height="100px" + backgroundColor="2" + borderRadius + > + {children} + </Column> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx new file mode 100644 index 0000000..0ec6e95 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx @@ -0,0 +1,22 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { endOfMonth, startOfMonth } from 'date-fns'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { useDateRange } from '@/components/hooks'; +import { Retention } from './Retention'; + +export function RetentionPage({ websiteId }: { websiteId: string }) { + const { + dateRange: { startDate }, + } = useDateRange(); + + const monthStartDate = startOfMonth(startDate); + const monthEndDate = endOfMonth(startDate); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} allowDateFilter={false} allowMonthFilter /> + <Retention websiteId={websiteId} startDate={monthStartDate} endDate={monthEndDate} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx new file mode 100644 index 0000000..2fbbc0a --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { RetentionPage } from './RetentionPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <RetentionPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Retention', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx new file mode 100644 index 0000000..0e782a1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx @@ -0,0 +1,152 @@ +import { Column, Grid, Row, Text } from '@umami/react-zen'; +import classNames from 'classnames'; +import { colord } from 'colord'; +import { useCallback, useMemo, useState } from 'react'; +import { BarChart } from '@/components/charts/BarChart'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Panel } from '@/components/common/Panel'; +import { TypeIcon } from '@/components/common/TypeIcon'; +import { useCountryNames, useLocale, useMessages, useResultQuery } from '@/components/hooks'; +import { CurrencySelect } from '@/components/input/CurrencySelect'; +import { ListTable } from '@/components/metrics/ListTable'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { renderDateLabels } from '@/lib/charts'; +import { CHART_COLORS } from '@/lib/constants'; +import { generateTimeSeries } from '@/lib/date'; +import { formatLongCurrency, formatLongNumber } from '@/lib/format'; + +export interface RevenueProps { + websiteId: string; + startDate: Date; + endDate: Date; + unit: string; +} + +export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) { + const [currency, setCurrency] = useState('USD'); + const { formatMessage, labels } = useMessages(); + const { locale, dateLocale } = useLocale(); + const { countryNames } = useCountryNames(locale); + const { data, error, isLoading } = useResultQuery<any>('revenue', { + websiteId, + startDate, + endDate, + currency, + }); + + const renderCountryName = useCallback( + ({ label: code }) => ( + <Row className={classNames(locale)} gap> + <TypeIcon type="country" value={code} /> + <Text>{countryNames[code] || formatMessage(labels.unknown)}</Text> + </Row> + ), + [countryNames, locale], + ); + + const chartData: any = useMemo(() => { + if (!data) return []; + + const map = (data.chart as any[]).reduce((obj, { x, t, y }) => { + if (!obj[x]) { + obj[x] = []; + } + + obj[x].push({ x: t, y }); + + return obj; + }, {}); + + return { + datasets: Object.keys(map).map((key, index) => { + const color = colord(CHART_COLORS[index % CHART_COLORS.length]); + return { + label: key, + data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale), + lineTension: 0, + backgroundColor: color.alpha(0.6).toRgbString(), + borderColor: color.alpha(0.7).toRgbString(), + borderWidth: 1, + }; + }), + }; + }, [data, startDate, endDate, unit]); + + const metrics = useMemo(() => { + if (!data) return []; + + const { sum, count, unique_count } = data.total; + + return [ + { + value: sum, + label: formatMessage(labels.total), + formatValue: n => formatLongCurrency(n, currency), + }, + { + value: count ? sum / count : 0, + label: formatMessage(labels.average), + formatValue: n => formatLongCurrency(n, currency), + }, + { + value: count, + label: formatMessage(labels.transactions), + formatValue: formatLongNumber, + }, + { + value: unique_count, + label: formatMessage(labels.uniqueCustomers), + formatValue: formatLongNumber, + }, + ] as any; + }, [data, locale]); + + const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); + + return ( + <Column gap> + <Grid columns="280px" gap> + <CurrencySelect value={currency} onChange={setCurrency} /> + </Grid> + <LoadingPanel data={data} isLoading={isLoading} error={error}> + {data && ( + <Column gap> + <MetricsBar> + {metrics?.map(({ label, value, formatValue }) => { + return ( + <MetricCard key={label} value={value} label={label} formatValue={formatValue} /> + ); + })} + </MetricsBar> + <Panel> + <BarChart + chartData={chartData} + minDate={startDate} + maxDate={endDate} + unit={unit} + stacked={true} + currency={currency} + renderXLabel={renderXLabel} + height="400px" + /> + </Panel> + <Panel> + <ListTable + title={formatMessage(labels.country)} + metric={formatMessage(labels.revenue)} + data={data?.country.map(({ name, value }: { name: string; value: number }) => ({ + label: name, + count: Number(value), + percent: (value / data?.total.sum) * 100, + }))} + currency={currency} + renderLabel={renderCountryName} + /> + </Panel> + </Column> + )} + </LoadingPanel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx new file mode 100644 index 0000000..3e429c1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx @@ -0,0 +1,18 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { useDateRange } from '@/components/hooks'; +import { Revenue } from './Revenue'; + +export function RevenuePage({ websiteId }: { websiteId: string }) { + const { + dateRange: { startDate, endDate, unit }, + } = useDateRange(); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <Revenue websiteId={websiteId} startDate={startDate} endDate={endDate} unit={unit} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx new file mode 100644 index 0000000..e30d54c --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx @@ -0,0 +1,21 @@ +import { DataColumn, DataTable } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { formatLongCurrency } from '@/lib/format'; + +export function RevenueTable({ data = [] }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DataTable data={data}> + <DataColumn id="currency" label={formatMessage(labels.currency)} align="end" /> + <DataColumn id="total" label={formatMessage(labels.total)} align="end"> + {(row: any) => formatLongCurrency(row.sum, row.currency)} + </DataColumn> + <DataColumn id="average" label={formatMessage(labels.average)} align="end"> + {(row: any) => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)} + </DataColumn> + <DataColumn id="count" label={formatMessage(labels.transactions)} align="end" /> + <DataColumn id="unique_count" label={formatMessage(labels.uniqueCustomers)} align="end" /> + </DataTable> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx new file mode 100644 index 0000000..fba10f1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { RevenuePage } from './RevenuePage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <RevenuePage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Revenue', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx new file mode 100644 index 0000000..1399174 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx @@ -0,0 +1,71 @@ +import { Column, Grid, Heading, Text } from '@umami/react-zen'; +import { PieChart } from '@/components/charts/PieChart'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Panel } from '@/components/common/Panel'; +import { useMessages, useResultQuery } from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants'; + +export interface UTMProps { + websiteId: string; + startDate: Date; + endDate: Date; +} + +export function UTM({ websiteId, startDate, endDate }: UTMProps) { + const { formatMessage, labels } = useMessages(); + const { data, error, isLoading } = useResultQuery<any>('utm', { + websiteId, + startDate, + endDate, + }); + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="300px"> + {data && ( + <Column gap> + {UTM_PARAMS.map(param => { + const items = data?.[param]; + + const chartData = { + labels: items.map(({ utm }) => utm), + datasets: [ + { + data: items.map(({ views }) => views), + backgroundColor: CHART_COLORS, + borderWidth: 0, + }, + ], + }; + const total = items.reduce((sum, { views }) => { + return +sum + +views; + }, 0); + + return ( + <Panel key={param}> + <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap="6"> + <Column> + <Heading> + <Text transform="capitalize">{param.replace(/^utm_/, '')}</Text> + </Heading> + <ListTable + metric={formatMessage(labels.views)} + data={items.map(({ utm, views }) => ({ + label: utm, + count: views, + percent: (views / total) * 100, + }))} + /> + </Column> + <Column> + <PieChart type="doughnut" chartData={chartData} /> + </Column> + </Grid> + </Panel> + ); + })} + </Column> + )} + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx new file mode 100644 index 0000000..0d2a732 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx @@ -0,0 +1,18 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { useDateRange } from '@/components/hooks'; +import { UTM } from './UTM'; + +export function UTMPage({ websiteId }: { websiteId: string }) { + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <UTM websiteId={websiteId} startDate={startDate} endDate={endDate} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx new file mode 100644 index 0000000..8b8fd6a --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { UTMPage } from './UTMPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <UTMPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'UTM Parameters', +}; |